use tracing::{span, Span};
use url::Url;

use crate::action::base::create_or_merge_nix_config::{
    CreateOrMergeNixConfigError, EXPERIMENTAL_FEATURES_CONF_NAME,
    EXTRA_EXPERIMENTAL_FEATURES_CONF_NAME, TRUSTED_USERS_CONF_NAME,
};
use crate::action::base::{CreateDirectory, CreateOrMergeNixConfig};
use crate::action::{
    Action, ActionDescription, ActionError, ActionErrorKind, ActionTag, StatefulAction,
};
use crate::distribution::Distribution;
use crate::parse_ssl_cert;
use crate::settings::UrlOrPathOrString;
use std::path::PathBuf;

pub const NIX_CONF_FOLDER: &str = "/etc/nix";
pub const NIX_CONF: &str = "/etc/nix/nix.conf";
const CUSTOM_NIX_CONF: &str = "/etc/nix/nix.custom.conf";

const NIX_CONFIG_HEADER: &str = r#"# Generated by https://github.com/DeterminateSystems/nix-installer.
# See `/nix/nix-installer --version` for the version details.
"#;

const NIX_CONFIG_FOOTER: &str = "!include nix.custom.conf";

pub(crate) const CUSTOM_NIX_CONFIG_HEADER: &str = r#"# Written by https://github.com/DeterminateSystems/nix-installer.
# The contents below are based on options specified at installation time.
"#;

/**
Place the `/etc/nix/nix.conf` file
 */
#[derive(Debug, serde::Deserialize, serde::Serialize, Clone)]
#[serde(tag = "action_name", rename = "place_nix_configuration")]
pub struct PlaceNixConfiguration {
    create_directory: StatefulAction<CreateDirectory>,
    create_or_merge_standard_nix_config: Option<StatefulAction<CreateOrMergeNixConfig>>,
    create_or_merge_custom_nix_config: StatefulAction<CreateOrMergeNixConfig>,
}

impl PlaceNixConfiguration {
    #[tracing::instrument(level = "debug", skip_all)]
    pub async fn plan(
        nix_build_group_name: String,
        proxy: Option<Url>,
        ssl_cert_file: Option<PathBuf>,
        extra_conf: Vec<UrlOrPathOrString>,
        force: bool,
        distribution: Distribution,
    ) -> Result<StatefulAction<Self>, ActionError> {
        let extra_conf = Self::parse_extra_conf(proxy, ssl_cert_file.as_ref(), extra_conf).await?;

        let is_macos = matches!(
            target_lexicon::OperatingSystem::host(),
            target_lexicon::OperatingSystem::MacOSX { .. }
                | target_lexicon::OperatingSystem::Darwin
        );
        let configured_ssl_cert_file = if distribution == Distribution::DeterminateNix && is_macos {
            // On macOS, determinate-nixd will handle configuring the ssl-cert-file option for Nix
            None
        } else {
            ssl_cert_file
        };

        let standard_nix_config = if distribution != Distribution::DeterminateNix {
            let maybe_trusted_users = extra_conf.settings().get(TRUSTED_USERS_CONF_NAME);

            Some(Self::setup_standard_config(maybe_trusted_users).await?)
        } else {
            None
        };

        let custom_nix_config = Self::setup_extra_config(
            extra_conf,
            nix_build_group_name,
            configured_ssl_cert_file.as_ref(),
        )
        .await?;

        let create_directory = CreateDirectory::plan(NIX_CONF_FOLDER, None, None, 0o0755, force)
            .await
            .map_err(Self::error)?;

        let create_or_merge_standard_nix_config =
            if let Some(standard_nix_config) = standard_nix_config {
                Some(
                    CreateOrMergeNixConfig::plan(
                        NIX_CONF,
                        standard_nix_config,
                        NIX_CONFIG_HEADER.to_string(),
                        Some(NIX_CONFIG_FOOTER.to_string()),
                    )
                    .await
                    .map_err(Self::error)?,
                )
            } else {
                None
            };

        let create_or_merge_custom_nix_config = CreateOrMergeNixConfig::plan(
            CUSTOM_NIX_CONF,
            custom_nix_config,
            CUSTOM_NIX_CONFIG_HEADER.to_string(),
            None,
        )
        .await
        .map_err(Self::error)?;

        Ok(Self {
            create_directory,
            create_or_merge_standard_nix_config,
            create_or_merge_custom_nix_config,
        }
        .into())
    }

    async fn setup_standard_config(
        maybe_trusted_users: Option<&String>,
    ) -> Result<nix_config_parser::NixConfig, ActionError> {
        let mut nix_config = nix_config_parser::NixConfig::new();
        let settings = nix_config.settings_mut();

        let experimental_features = ["nix-command", "flakes"];
        settings.insert(
            "extra-experimental-features".to_string(),
            experimental_features.join(" "),
        );

        // https://github.com/DeterminateSystems/nix-installer/issues/449#issuecomment-1551782281
        #[cfg(not(target_os = "macos"))]
        settings.insert("auto-optimise-store".to_string(), "true".to_string());

        // https://github.com/NixOS/nix/pull/8047
        settings.insert("always-allow-substitutes".to_string(), "true".to_string());

        // base, unintrusive Determinate Nix options
        {
            // Add FlakeHub cache to the list of possible substituters, but disabled by default.
            // This allows a user to turn on FlakeHub Cache by adding it to the `extra-substituters`
            // list without being a trusted user.
            settings.insert(
                "extra-trusted-substituters".to_string(),
                "https://cache.flakehub.com".to_string(),
            );

            // Add FlakeHub's cache signing keys to the allowed list, but unused unless a user
            // specifies FlakeHub Cache as an `extra-substituter`.
            let extra_trusted_public_keys = [
                "cache.flakehub.com-3:hJuILl5sVK4iKm86JzgdXW12Y2Hwd5G07qKtHTOcDCM=",
                "cache.flakehub.com-4:Asi8qIv291s0aYLyH6IOnr5Kf6+OF14WVjkE6t3xMio=",
                "cache.flakehub.com-5:zB96CRlL7tiPtzA9/WKyPkp3A2vqxqgdgyTVNGShPDU=",
                "cache.flakehub.com-6:W4EGFwAGgBj3he7c5fNh9NkOXw0PUVaxygCVKeuvaqU=",
                "cache.flakehub.com-7:mvxJ2DZVHn/kRxlIaxYNMuDG1OvMckZu32um1TadOR8=",
                "cache.flakehub.com-8:moO+OVS0mnTjBTcOUh2kYLQEd59ExzyoW1QgQ8XAARQ=",
                "cache.flakehub.com-9:wChaSeTI6TeCuV/Sg2513ZIM9i0qJaYsF+lZCXg0J6o=",
                "cache.flakehub.com-10:2GqeNlIp6AKp4EF2MVbE1kBOp9iBSyo0UPR9KoR0o1Y=",
            ];
            settings.insert(
                "extra-trusted-public-keys".to_string(),
                extra_trusted_public_keys.join(" "),
            );
        }

        settings.insert(
            "bash-prompt-prefix".to_string(),
            "(nix:$name)\\040".to_string(),
        );
        settings.insert("max-jobs".to_string(), "auto".to_string());
        settings.insert(
            "extra-nix-path".to_string(),
            "nixpkgs=flake:nixpkgs".to_string(),
        );
        settings.insert(
            "upgrade-nix-store-path-url".to_string(),
            "https://install.determinate.systems/nix-upgrade/stable/universal".to_string(),
        );

        // NOTE(cole-h): This is a workaround to hopefully unbreak users of Cachix.
        // When `cachix use`ing a cache, the Cachix CLI will sanity-check the system configuration
        // at `/etc/nix/nix.conf` to ensure that the user doing this will actually be able to
        // configure trusted settings (such as `trusted-public-keys`).
        // However, because we now write the `--extra-conf` into the `nix.custom.conf` (which is how
        // users, including our first-party DeterminateSystems/nix-installer-action, would configure
        // the `trusted-users` setting), and Cachix does not currently handle `include`s
        // properly[1][2], Cachix bails out thinking that the user is not a trusted user[3] even
        // though it is (it's just configured in another file).
        //
        // [1]: https://github.com/cachix/cachix/issues/680
        // [2]: https://github.com/cachix/cachix/pull/681
        // [3]: https://github.com/DeterminateSystems/nix-installer/issues/1389
        if let Some(trusted_users) = maybe_trusted_users {
            settings.insert(
                TRUSTED_USERS_CONF_NAME.to_string(),
                trusted_users.to_owned(),
            );
        }

        Ok(nix_config)
    }

    async fn parse_extra_conf(
        proxy: Option<Url>,
        ssl_cert_file: Option<&PathBuf>,
        extra_conf: Vec<UrlOrPathOrString>,
    ) -> Result<nix_config_parser::NixConfig, ActionError> {
        let mut extra_conf_text = vec![];
        for extra in extra_conf {
            let buf = match &extra {
                UrlOrPathOrString::Url(url) => match url.scheme() {
                    "https" | "http" => {
                        let mut buildable_client = reqwest::Client::builder();
                        if let Some(proxy) = &proxy {
                            buildable_client = buildable_client.proxy(
                                reqwest::Proxy::all(proxy.clone())
                                    .map_err(ActionErrorKind::Reqwest)
                                    .map_err(Self::error)?,
                            )
                        }
                        if let Some(ssl_cert_file) = &ssl_cert_file {
                            let ssl_cert =
                                parse_ssl_cert(ssl_cert_file).await.map_err(Self::error)?;
                            buildable_client = buildable_client.add_root_certificate(ssl_cert);
                        }
                        let client = buildable_client
                            .build()
                            .map_err(ActionErrorKind::Reqwest)
                            .map_err(Self::error)?;
                        let req = client
                            .get(url.clone())
                            .build()
                            .map_err(ActionErrorKind::Reqwest)
                            .map_err(Self::error)?;
                        let res = client
                            .execute(req)
                            .await
                            .map_err(ActionErrorKind::Reqwest)
                            .map_err(Self::error)?;
                        res.text()
                            .await
                            .map_err(ActionErrorKind::Reqwest)
                            .map_err(Self::error)?
                    },
                    "file" => tokio::fs::read_to_string(url.path())
                        .await
                        .map_err(|e| ActionErrorKind::Read(PathBuf::from(url.path()), e))
                        .map_err(Self::error)?,
                    _ => return Err(Self::error(ActionErrorKind::UnknownUrlScheme)),
                },
                UrlOrPathOrString::Path(path) => tokio::fs::read_to_string(path)
                    .await
                    .map_err(|e| ActionErrorKind::Read(PathBuf::from(path), e))
                    .map_err(Self::error)?,
                UrlOrPathOrString::String(string) => string.clone(),
            };
            extra_conf_text.push(buf)
        }

        let extra_conf = extra_conf_text.join("\n");
        let nix_config = nix_config_parser::NixConfig::parse_string(extra_conf, None)
            .map_err(CreateOrMergeNixConfigError::ParseNixConfig)
            .map_err(Self::error)?;

        Ok(nix_config)
    }

    async fn setup_extra_config(
        mut extra_conf: nix_config_parser::NixConfig,
        nix_build_group_name: String,
        ssl_cert_file: Option<&PathBuf>,
    ) -> Result<nix_config_parser::NixConfig, ActionError> {
        let settings = extra_conf.settings_mut();

        if nix_build_group_name != crate::settings::DEFAULT_NIX_BUILD_USER_GROUP_NAME {
            settings.insert("build-users-group".to_string(), nix_build_group_name);
        }

        if let Some(ssl_cert_file) = ssl_cert_file {
            let ssl_cert_file_canonical = ssl_cert_file.canonicalize().map_err(|e| {
                Self::error(ActionErrorKind::Canonicalize(ssl_cert_file.to_owned(), e))
            })?;
            settings.insert(
                "ssl-cert-file".to_string(),
                ssl_cert_file_canonical.display().to_string(),
            );
        }

        // NOTE(cole-h): We want to ensure our experimental-features are not clobbered by user
        // config, so if a user specifies that, we exchange it for the `extra-` variant that just
        // appends to the list of experimental features.
        // If the user does indeed want to completely overwrite our experimental features, they can
        // modify nix.custom.conf to their heart's desire. In most cases, however, they likely just
        // want their setting to take effect (and probably have no opinion on or would even prefer
        // our standard experimental features continue to take effect).
        if let Some((idx, _key, experimental_features)) =
            settings.shift_remove_full(EXPERIMENTAL_FEATURES_CONF_NAME)
        {
            tracing::debug!(
                "User specified {EXPERIMENTAL_FEATURES_CONF_NAME} in extra-conf, but this would \
                override our desired configuration, so force it to be \
                {EXTRA_EXPERIMENTAL_FEATURES_CONF_NAME} instead"
            );
            settings.shift_insert(
                idx,
                EXTRA_EXPERIMENTAL_FEATURES_CONF_NAME.to_string(),
                experimental_features,
            );
        }

        Ok(extra_conf)
    }
}

#[async_trait::async_trait]
#[typetag::serde(name = "place_nix_configuration")]
impl Action for PlaceNixConfiguration {
    fn action_tag() -> ActionTag {
        ActionTag("place_nix_configuration")
    }
    fn tracing_synopsis(&self) -> String {
        format!("Place the Nix configuration in `{NIX_CONF}`")
    }

    fn tracing_span(&self) -> Span {
        span!(tracing::Level::DEBUG, "place_nix_configuration",)
    }

    fn execute_description(&self) -> Vec<ActionDescription> {
        let mut explanation = vec![
            "This file is read by the Nix daemon to set its configuration options at runtime."
                .to_string(),
        ];

        if let Some(val) = self.create_directory.describe_execute().first() {
            explanation.push(val.description.clone())
        }
        if let Some(ref standard_config) = self.create_or_merge_standard_nix_config {
            for val in standard_config.describe_execute().iter() {
                explanation.push(val.description.clone())
            }
        }
        for val in self
            .create_or_merge_custom_nix_config
            .describe_execute()
            .iter()
        {
            explanation.push(val.description.clone())
        }

        vec![ActionDescription::new(self.tracing_synopsis(), explanation)]
    }

    #[tracing::instrument(level = "debug", skip_all)]
    async fn execute(&mut self) -> Result<(), ActionError> {
        self.create_directory
            .try_execute()
            .await
            .map_err(Self::error)?;
        if let Some(ref mut standard_config) = self.create_or_merge_standard_nix_config {
            standard_config.try_execute().await.map_err(Self::error)?;
        } else {
            let mut command = tokio::process::Command::new("/usr/local/bin/determinate-nixd");
            command.args(["init", "--stop-after", "nix-configuration"]);
            command.stderr(std::process::Stdio::piped());
            command.stdout(std::process::Stdio::piped());
            tracing::trace!(command = ?command.as_std(), "Initializing nix.conf");
            let output = command
                .output()
                .await
                .map_err(|e| ActionErrorKind::command(&command, e))
                .map_err(Self::error)?;
            if !output.status.success() {
                return Err(Self::error(ActionErrorKind::command_output(
                    &command, output,
                )));
            }
        }

        self.create_or_merge_custom_nix_config
            .try_execute()
            .await
            .map_err(Self::error)?;

        Ok(())
    }

    fn revert_description(&self) -> Vec<ActionDescription> {
        vec![ActionDescription::new(
            format!("Remove the Nix configuration in `{NIX_CONF}`"),
            vec![
                "This file is read by the Nix daemon to set its configuration options at runtime."
                    .to_string(),
            ],
        )]
    }

    #[tracing::instrument(level = "debug", skip_all)]
    async fn revert(&mut self) -> Result<(), ActionError> {
        let mut errors = vec![];
        if let Err(err) = self.create_or_merge_custom_nix_config.try_revert().await {
            errors.push(err);
        }

        if let Some(ref mut standard_config) = self.create_or_merge_standard_nix_config {
            if let Err(err) = standard_config.try_revert().await {
                errors.push(err);
            }
        }

        if let Err(err) = self.create_directory.try_revert().await {
            errors.push(err);
        }

        if errors.is_empty() {
            Ok(())
        } else if errors.len() == 1 {
            Err(errors
                .into_iter()
                .next()
                .expect("Expected 1 len Vec to have at least 1 item"))
        } else {
            Err(Self::error(ActionErrorKind::MultipleChildren(errors)))
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[tokio::test]
    async fn extra_trusted_cache() -> eyre::Result<()> {
        let extra_conf = PlaceNixConfiguration::parse_extra_conf(
            None,
            None,
            vec![
                UrlOrPathOrString::String(String::from("extra-trusted-substituters = barfoo")),
                UrlOrPathOrString::String(String::from("extra-trusted-public-keys = foobar")),
            ],
        )
        .await?;

        let nix_config =
            PlaceNixConfiguration::setup_extra_config(extra_conf, String::from("foo"), None)
                .await?;

        assert!(
            nix_config
                .settings()
                .get("extra-trusted-substituters")
                .unwrap()
                .contains("barfoo"),
            "User config and internal defaults are both respected"
        );

        assert!(
            nix_config
                .settings()
                .get("extra-trusted-public-keys")
                .unwrap()
                .contains("foobar"),
            "User config and internal defaults are both respected"
        );

        Ok(())
    }

    #[tokio::test]
    async fn experimental_features() -> eyre::Result<()> {
        let nix_conf_dir = tempfile::tempdir()?;
        let nix_conf_path = nix_conf_dir.path().join("nix.conf");
        let nix_custom_conf_path = nix_conf_dir.path().join("nix.custom.conf");

        let extra_conf = PlaceNixConfiguration::parse_extra_conf(
            None,
            None,
            vec![UrlOrPathOrString::String(format!(
                "{EXPERIMENTAL_FEATURES_CONF_NAME} = foobar"
            ))],
        )
        .await?;

        let standard_nix_config = PlaceNixConfiguration::setup_standard_config(None).await?;
        let custom_nix_config =
            PlaceNixConfiguration::setup_extra_config(extra_conf, String::from("foo"), None)
                .await?;
        dbg!(&custom_nix_config);
        dbg!(custom_nix_config.settings());
        dbg!(custom_nix_config
            .settings()
            .get(EXTRA_EXPERIMENTAL_FEATURES_CONF_NAME));

        assert!(
            custom_nix_config
                .settings()
                .get(EXPERIMENTAL_FEATURES_CONF_NAME)
                .is_none(),
            "experimental-features in `--extra-conf` was renamed to extra-experimental-features"
        );
        assert!(
            custom_nix_config
                .settings()
                .get(EXTRA_EXPERIMENTAL_FEATURES_CONF_NAME)
                .unwrap()
                .contains("foobar"),
            "experimental-features in `--extra-conf` was renamed to extra-experimental-features"
        );

        let mut place_nix_configuration = StatefulAction::uncompleted(PlaceNixConfiguration {
            create_directory: StatefulAction::completed(CreateDirectory {
                path: nix_conf_dir.path().to_owned(),
                user: None,
                group: None,
                mode: None,
                is_mountpoint: false,
                force_prune_on_revert: false,
            }),
            create_or_merge_standard_nix_config: Some(
                CreateOrMergeNixConfig::plan(
                    &nix_conf_path,
                    standard_nix_config,
                    NIX_CONFIG_HEADER.to_string(),
                    Some(NIX_CONFIG_FOOTER.to_string()),
                )
                .await
                .map_err(PlaceNixConfiguration::error)?,
            ),
            create_or_merge_custom_nix_config: CreateOrMergeNixConfig::plan(
                &nix_custom_conf_path,
                custom_nix_config,
                CUSTOM_NIX_CONFIG_HEADER.to_string(),
                None,
            )
            .await
            .map_err(PlaceNixConfiguration::error)?,
        });

        place_nix_configuration
            .try_execute()
            .await
            .expect("place nix config should succeed");

        let custom_conf = tokio::fs::read_to_string(nix_custom_conf_path)
            .await
            .unwrap();
        assert!(
            custom_conf.contains(EXTRA_EXPERIMENTAL_FEATURES_CONF_NAME)
                && custom_conf.contains("foobar"),
            "experimental-features in `--extra-conf` was renamed to extra-experimental-features"
        );

        Ok(())
    }

    #[tokio::test]
    async fn extra_trusted_users() -> eyre::Result<()> {
        let nix_conf_dir = tempfile::tempdir()?;
        let nix_conf_path = nix_conf_dir.path().join("nix.conf");
        let nix_custom_conf_path = nix_conf_dir.path().join("nix.custom.conf");

        let extra_conf = PlaceNixConfiguration::parse_extra_conf(
            None,
            None,
            vec![UrlOrPathOrString::String(String::from(
                "trusted-users = bob alice",
            ))],
        )
        .await?;

        let maybe_trusted_users = extra_conf.settings().get(TRUSTED_USERS_CONF_NAME);

        let standard_nix_config =
            PlaceNixConfiguration::setup_standard_config(maybe_trusted_users).await?;
        let custom_nix_config =
            PlaceNixConfiguration::setup_extra_config(extra_conf, String::from("foo"), None)
                .await?;

        assert!(
            custom_nix_config
                .settings()
                .get("trusted-users")
                .unwrap()
                .contains("bob"),
            "User config and internal defaults are both respected"
        );
        assert!(
            standard_nix_config
                .settings()
                .get("trusted-users")
                .unwrap()
                .contains("bob"),
            "User config and internal defaults are both respected"
        );

        let mut place_nix_configuration = StatefulAction::uncompleted(PlaceNixConfiguration {
            create_directory: StatefulAction::completed(CreateDirectory {
                path: nix_conf_dir.path().to_owned(),
                user: None,
                group: None,
                mode: None,
                is_mountpoint: false,
                force_prune_on_revert: false,
            }),
            create_or_merge_standard_nix_config: Some(
                CreateOrMergeNixConfig::plan(
                    &nix_conf_path,
                    standard_nix_config,
                    NIX_CONFIG_HEADER.to_string(),
                    Some(NIX_CONFIG_FOOTER.to_string()),
                )
                .await
                .map_err(PlaceNixConfiguration::error)?,
            ),
            create_or_merge_custom_nix_config: CreateOrMergeNixConfig::plan(
                &nix_custom_conf_path,
                custom_nix_config,
                CUSTOM_NIX_CONFIG_HEADER.to_string(),
                None,
            )
            .await
            .map_err(PlaceNixConfiguration::error)?,
        });

        place_nix_configuration
            .try_execute()
            .await
            .expect("place nix config should succeed");

        let standard_conf = tokio::fs::read_to_string(nix_conf_path).await.unwrap();
        assert!(standard_conf.contains("trusted-user"), "trusted-user setting should exist in standard conf so that we don't break cachix users");

        let custom_conf = tokio::fs::read_to_string(nix_custom_conf_path)
            .await
            .unwrap();
        assert!(
            custom_conf.contains("trusted-user"),
            "trusted-user setting should exist in custom conf as well"
        );

        Ok(())
    }
}
